Skip to main content

05 - MCP 网关:一个客户端,多个后端

数据快照 2026-08-19。代码引自当天拉取的 envoyproxy/ai-gateway@mainagentgateway/agentgateway@main

前三篇讲的都是"业务 → 模型"这段流量。这一篇讲的是另一段:"Agent → 工具"

这段流量在 2026 年基本被 MCP 统一了,而它带来的问题和 LLM 流量完全不同:

  • LLM 请求是无状态的,MCP 连接是有状态的(有 session、有订阅、有服务端主动推送)
  • LLM 后端是同质的(都能回答同一个问题),MCP 后端是异质的(每个提供不同的工具)
  • LLM 网关做的是二选一,MCP 网关做的是聚合

所以 MCP 网关不是"给 MCP 做个反向代理",它更像一个协议感知的聚合器

读这篇之前

前置01 - 网关是什么 里的 MCP 概念(让 AI 应用连接外部工具的协议,提供工具的一方叫 MCP Server)。

本篇回答:Agent 要连 5 个 MCP Server,网关怎么把它们伪装成一个?

会用到的词

  • JSON-RPC:MCP 使用的消息格式,每条消息有 method(方法名)和 id(用来把响应和请求配对)
  • tools/list / tools/call:MCP 里最核心的两个方法 —— 列出有哪些工具、调用某个工具
  • session(会话):MCP 连接是有状态的,客户端和服务端靠一个 session ID 维持上下文,这是它和普通 HTTP 请求最大的区别
  • SSE(Server-Sent Events):服务端向客户端单向推送消息的长连接,MCP 用它来做服务端主动通知

一、核心问题:客户端只想看到一个 MCP Server

客户端发一次 tools/list,期望拿回一份完整列表。但这份列表实际来自三个后端,而且:

  • 三个后端各有各的 session ID
  • 三个后端可能有同名工具
  • 任何一个后端推送 tools/list_changed,都要转发给客户端
  • 客户端调用某个工具时,要知道该发给谁

Envoy AI Gateway 的 internal/mcpproxy/handlers.go 1,904 行 + session.go 801 行)就是在解决这四件事。

二、工具命名:用 __ 做后端命名空间

最朴素也最关键的一步 —— 给工具名加后端前缀:

// internal/mcpproxy/handlers.go
const nameSeparator = "__"

func downstreamResourceName(name string, backendName string) string {
return fmt.Sprintf("%s%s%s", backendName, nameSeparator, name)
}

所以后端 github 上的 search_issues 工具,客户端看到的是 github__search_issues。调用时反向解析:

func (m *mcpRequestContext) handleToolCallRequest(...) (handlerResult, error) {
backendName, toolName, err := upstreamResourceName(p.Name)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err))
return handlerResult{}, err
}
backend, err := m.getBackendForRoute(s.route, backendName)
// ...
p.Name = toolName // 发给后端时把前缀去掉

这个方案朴素但有代价:工具名会变长,而工具名和描述是要塞进模型上下文的。三十个工具、每个前缀多十几个字符,就是几百个 token 的固定开销。同时它也解释了为什么 MCP 生态里工具名普遍不能带 __

同样的前缀技巧还用在了请求 ID 上,因为服务端可以反向给客户端发请求(比如采样),响应回来时得知道是哪个后端问的:

prefixedID = fmt.Sprintf("%d%si%s%s", v, nameSeparator, nameSeparator, backend)

后面那个 i / f / s 是原始 ID 的类型标记(int / float / string)—— JSON-RPC 的 ID 可以是数字也可以是字符串,加了前缀后必须能还原回原来的类型。这种细节是"给一个有状态协议做代理"和"给 HTTP 做代理"的真实难度差距。

三、会话:一个对客户端,N 个对后端

type session struct {
route string
perBackendSessions map[filterapi.MCPBackendName]*compositeSessionEntry
// extraHeaders contains header values extracted from the current HTTP request to be forwarded to ALL backends.
// These are derived from the route's configured forward headers (e.g., OAuth claimToHeaders) and the current request's headers.
extraHeaders ...
// perBackendExtraHeaders contains per-backend header values extracted from the current HTTP request.
perBackendExtraHeaders ...
}

一个客户端 session 内部维护一张 后端 → 后端 session 的映射。请求头的转发分两层:一层广播给所有后端,一层按后端定制(不同后端的鉴权凭证显然不能混用)。

session ID 走标准头:

const sessionIDHeader = "mcp-session-id"

生命周期结束时逐个通知后端:

func (s *session) Close() error {
for backendName, sess := range s.perBackendSessions {
sessionID := sess.sessionID
if sessionID == "" {
// Stateless backend, nothing to do.
continue
}
req, err := http.NewRequest(http.MethodDelete, s.reqCtx.backendListenerAddr, nil)
// ...
req.Header.Set(sessionIDHeader, sessionID.String())
// ...
// Some stateless backends may return 404 Not Found if they don't track sessions.
}
}

两处注释暴露了真实世界的混乱:有些后端是无状态的,根本不发 session ID;有些后端收到 DELETE 会回 404。 网关必须容忍这两种情况,不能因为后端不守规矩就报错。

initialize 是唯一不需要 session 的方法:

// We do require a Session ID. If it is not present for requests other than initialize,
if s == nil && msg.Method != "initialize" {

四、通知流:把 N 个 SSE 流合成一个

MCP 允许服务端主动推送。客户端只开一条 GET 流,网关得把 N 个后端的流合并进去:

// streamNotifications streams notifications from all backends in this session to the given writer.
func (s *session) streamNotifications(ctx context.Context, w http.ResponseWriter, toolChangeSignaler changeSignaler) error {
backendMsgs := s.sendToAllBackends(ctx, http.MethodGet, nil, nil, nil)
for {
select {
// events received from the upstream MCP backends
case event, ok := <-backendMsgs:
if !ok {
// All backend notification streams have ended (e.g. backends returned 405

同时网关自己也会往这条流里塞两种消息:

func newHeartBeatPingMessage() *jsonrpc.Request
func newToolListChangedMessage() *jsonrpc.Request

心跳是为了保活(SSE 长连接经过中间设备容易被静默掐断),tools/list_changed 是因为后端集合本身可能变化 —— 某个后端下线了,客户端手里的工具列表就该更新。

断线重连靠 lastEventID,而这个 ID 是加密的:

encrypted, err := s.reqCtx.sessionCrypto.Encrypt(lastEventID)

因为它是 N 个后端 event ID 的拼接,不加密就等于把内部拓扑(有几个后端、叫什么)泄露给客户端

五、授权:细到单个工具

Envoy AI Gateway 在 tools/call 上做两层检查。第一层是路由级白名单:

selector := route.toolSelectors[backendName]
if selector != nil && !selector.allows(toolName) {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name: %s", toolName))
return result, fmt.Errorf("%w: %s", errInvalidToolName, toolName)
}

第二层是带 scope 的授权:

allowed, requiredScopes := m.authorizeRequest(route.authorization, &authorizationRequest{
Headers: r.Header,
HTTPMethod: r.Method,
Host: r.Host,
HTTPPath: httpPath,
MCPMethod: req.Method,
Backend: backendName,
Tool: toolName,
Params: p,
})
if !allowed {
// Specify the minimum required scopes in the WWW-Authenticate header.
// Reference: .../specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors

注意授权请求里带了 Params —— 也就是说策略可以基于工具参数做判断,而不只是工具名。"允许调用 delete_file,但只在 /tmp 下"这种规则是能写出来的。这是 Agent 场景相比传统 API 网关最本质的新需求:同一个工具,参数不同,风险差几个数量级。

被拒时通过 WWW-Authenticate 头告知所需 scope,这直接对应 MCP 规范里的 runtime insufficient-scope 错误处理。

六、agentgateway 的做法:CEL 表达式

agentgateway 是 Rust 实现,crates/agentgateway/src/mcp/handler.rs 72 KB、auth.rs 38 KB、session.rs 36 KB、streamablehttp.rs 19 KB)解决的是同一批问题,但策略表达方式完全不同 —— 它用 CEL(Common Expression Language),而且专门 fork 了一份 CEL 实现放在 crates/cel-fork/

mcp/auth.rs 里能看到几个很具体的工程决策:

"MCP auth configured; validating Authorization header (mode={:?})",
let issuer = auth.issuer.trim_end_matches('/');

issuer 末尾斜杠要裁掉 —— OAuth issuer 的尾斜杠不一致是经典踩坑点,配置里写 https://example.com/ 而 token 里是 https://example.com,校验就失败。

if matches!(auth.provider, Some(McpIDP::Entra {}))

Microsoft Entra 需要单独 case。 文件里出现了两处针对 Entra 的分支,说明它在某些行为上偏离了通用 OAuth 流程。企业落地绕不开这个。

matches!(grant_type, Some("authorization_code" | "refresh_token"))

只接受这两种 grant type,其他一律拒绝。

llm/policy/mod.rs(2,156 行)里还有一个很实用的设计 —— 模型别名的通配符匹配:

pub struct ModelAliasPattern {
#[serde(with = "serde_regex")]
regex: regex::Regex,
// Stores the compiled regex and original pattern length for specificity sorting.
}

impl ModelAliasPattern {
pub fn from_wildcard(pattern: &str) -> Result<Self, String> {
// Convert wildcard to regex: escape all chars, then replace \* with (.*)
let escaped = regex::escape(pattern);
let regex_pattern = escaped.replace(r"\*", "(.*)");
// ...
}

pub fn specificity(&self) -> usize { ... }
}

通配符转正则,按原始模式长度排序决定优先级 —— gpt-4*gpt-4o* 同时匹配时,更长的(更具体的)那个赢。这是路由规则里"最长前缀匹配"思想的直接搬用。

七、两家对比

Envoy AI Gatewayagentgateway
语言GoRust
MCP 代码量mcpproxy/ 约 130 KBmcp/ 约 165 KB
策略表达K8s CRD + Go 代码CEL 表达式(自带 fork)
工具命名空间backend__tool同类机制
授权粒度后端 / 工具 / 参数后端 / 工具 / 参数(CEL)
配置方式MCPRoute CRD配置文件 / xDS
前置依赖Kubernetes + Envoy

选型判断很直接:已经在 K8s 上跑 Envoy 就用前者,否则用后者。能力上两家已经相当接近,差别主要在"策略写在 YAML 里还是写成表达式"这个偏好上。

八、这一层往后会长成什么样

三个已经能看到苗头的方向:

  1. 工具列表本身要被裁剪。 现在网关是把 N 个后端的工具全量聚合返回。等后端数量上去,tools/list 的结果会大到塞不进上下文。裁剪逻辑放在网关是最合适的 —— 它是唯一同时看得见"有哪些工具"和"用户是谁"的地方。

  2. 工具调用要被计费。 现在配额只算 LLM token(见 03 - 多租户与配额),但一次昂贵的工具调用可能比一次模型调用贵得多。

  3. 工具是攻击面。 tools/list 返回的描述文本会直接进模型上下文 —— 一个恶意 MCP server 可以在工具描述里藏提示注入。网关看得见所有工具描述,是做检测的天然位置。这条线属于下一个专题。

下一篇05 - 性能与形态代价:LiteLLM 为什么要用 Rust 重写内核,四种形态各要付多少运行时开销。